// CommandLineHelper Library
// =========================
// License: Microsoft Permissive License (Ms-PL) v1.1
// Author: Rhy Mednick (http://www.rhyverse.net)
// Project Site: http://www.codeplex.com/CommandLineHelper

using System;
using System.Collections.Generic;
using System.Text;
using CommandLineHelper.Attributes;
using System.Reflection;

namespace CommandLineHelper
{
    /// <summary>
    /// The CommandLineHelper class parses command line attributes and assigns them to properties in a class decorated with the CommandLineHelper attributes.
    /// </summary>
    public class CommandLineHelper
    {
        private class PropertyDictionary : Dictionary<string, ItemPropertyInfo> { }

        //private constructor insures that nobody tries to create this object (since that is meaningless)
        private CommandLineHelper() { }
        /// <summary>
        /// Parses the command line parameters and assigns the values to the coresponding properties in the specified class.
        /// </summary>
        /// <typeparam name="T">The type of the target class</typeparam>
        /// <param name="target">The class to assign the command line parameters to</param>
        /// <returns>True if the parsing was successful.</returns>
        public static bool Parse<T>(ref T target)
        {
            return Parse<T>(ref target, true, true);
        }
        /// <summary>
        /// Parses the command line parameters and assigns the values to the coresponding properties in the specified class.
        /// </summary>
        /// <typeparam name="T">The type of the target class</typeparam>
        /// <param name="target">The class to assign the command line parameters to</param>
        /// <param name="showHelp">Flag indicating if help should be displayed on error or when there is a command line parameter requesting help ("-help", for example)</param>
        /// <returns>True if the parsing was successful.</returns>
        public static bool Parse<T>(ref T target, bool showHelp)
        {
            return Parse<T>(ref target, showHelp, true);
        }

        /// <summary>
        /// Parses the command line parameters and assigns the values to the coresponding properties in the specified class.
        /// </summary>
        /// <typeparam name="T">The type of the target class</typeparam>
        /// <param name="target">The class to assign the command line parameters to</param>
        /// <param name="showHelp">Flag indicating if help should be displayed on error or when there is a command line parameter requesting help ("-help", for example)</param>
        /// <param name="supressExceptions">Flag indicating if exceptions should be supressed.  If false, exceptions will be propogated to the caller.</param>
        /// <returns>True if the parsing was successful.</returns>
        public static bool Parse<T>(ref T target, bool showHelp, bool supressExceptions)
        {
            bool retval = true;
            PropertyDictionary propertyData = null;
            const string DETAILED_HELP_STRING = "detailedhelp";
            const string HELP_STRING = "help";

            try
            {
                //get the attributes from the type passed in
                Type type = target.GetType();
                //check to see if the arguments should be validated
                bool validateArgs = type.IsDefined(typeof(ValidateArgumentsAttribute), false);
                propertyData = GetPropertyDataFromTarget(target);

                //* we're going to parse the command line parameters by the process of elimination.  This will 
                //  help us make sure we don't miss anything
                //* Ignore item 0 because this is the name of the executable and we don't have a use for it
                //* Scan the list looking for anything that matches the name of one of our properties
                string[] arguments = Environment.GetCommandLineArgs();
                int argCount = arguments.Length;
                arguments[0] = String.Empty;
                StringBuilder leftoverParams = new StringBuilder();
                for (int i = 1; i < arguments.Length; i++)
                {
                    if (arguments[i][0] == '-' || arguments[i][0] == '/')
                    {
                        //check to see if this parameter is one asking for a help listing and if it is 
                        //display the usage info and abort because a help request overrides all else.
                        string paramName = arguments[i].Substring(1);
                        int matchCount = GetMatchCount(paramName, ref propertyData);

                        #region Check for help request
                        if (showHelp)
                        {
                            if (DETAILED_HELP_STRING.IndexOf(paramName, StringComparison.CurrentCultureIgnoreCase) == 0)
                            {
                                //this partial match could be an exact match as well.  If it is, then show help.
                                //if it's not an exact match then only show help if there are no other partial matches
                                if ((paramName.Length == DETAILED_HELP_STRING.Length) || (matchCount == 0))
                                {
                                    //no partial match for any of the properties we have, so this is a help request
                                    ShowDetailedHelp(ref propertyData);
                                    retval = false;
                                    return retval;
                                }
                                else if (matchCount > 0)
                                {
                                    throw new Exception(String.Format("The command line value '{0}' could refer to more than one expected parameter.", paramName));
                                }
                            }
                            else if ((HELP_STRING.IndexOf(paramName, StringComparison.CurrentCultureIgnoreCase) == 0) || (paramName=="?"))
                            {
                                //partial match for help, so see if we have partial match for anything else
                                if ((paramName.Length == HELP_STRING.Length) || (matchCount == 0))
                                {
                                    //no partial matches, so this is a help request
                                    ShowHelp(ref propertyData);
                                    retval = false;
                                    return retval;
                                }
                                else if (matchCount > 0)
                                {
                                    throw new Exception(String.Format("The command line value '{0}' could refer to more than one expected parameter.", paramName));
                                }
                            }

                        }
                        #endregion Check for help request

                        ItemPropertyInfo propInfo = null;
                        //check for property matching parameter name
                        if (propertyData.ContainsKey(paramName))
                        {
                            //exact match trumps partial match
                            propInfo = propertyData[paramName];
                        }
                        else if (matchCount == 1)
                        {
                            //find the match
                            foreach (string listItem in propertyData.Keys)
                            {
                                //look for a match by checking to see if the value is the first part of the comparison string
                                if (listItem.IndexOf(paramName, StringComparison.CurrentCultureIgnoreCase) == 0)
                                {
                                    propInfo = propertyData[listItem];
                                    break; //stop looking
                                }
                            }
                        }
                        else if (matchCount > 1)
                        {
                            //if we have more than one match then we have an ambiguous parameter
                            throw new Exception(String.Format("The command line value '{0}' could refer to more than one expected parameter.", paramName));
                        }

                        string paramValue = GetParameterValueByIndex(i, arguments);
                        //if we have a parameter value then we've read ahead in the arguments list and don't need the next
                        //item anymore.  Increment our counter to skip the item in the next iteration of the loop.
                        if (paramValue != String.Empty)
                            i++;

                        if (propInfo == null)
                        {
                            //no property match, so store this parameter as leftover data
                            leftoverParams.AppendFormat("{0} ", propInfo);
                            if (paramValue != String.Empty)
                            {
                                leftoverParams.AppendFormat("{0} ", paramValue);
                            }
                        }
                        else
                        {
                            //we know the parameter that we match and the value, so set it
                            if (propInfo.PropertyInfo.PropertyType == typeof(Boolean))
                            {
                                if (paramValue == String.Empty)
                                {
                                    //we expect this to be empty if we have a boolean type, so this is good
                                    propInfo.PropertyInfo.SetValue(target, true, null);
                                }
                                else
                                {
                                    throw new Exception(String.Format("A value was specified for the '{0}' parameter but none was expected.", paramName));
                                }
                            }
                            else
                            {
                                if (paramValue == String.Empty)
                                {
                                    //we're not expecting an empty string, so this is bad
                                    throw new Exception(String.Format("No value was specified for the '{0}' parameter but one was expected.", paramName));
                                }
                                else
                                {
                                    object convertedVal = ConvertParameter(paramValue, propInfo.PropertyInfo.PropertyType);
                                    propInfo.PropertyInfo.SetValue(target, convertedVal, null);
                                }
                            }
                        }
                    }
                    else
                    {
                        //if we've gotten here then we have a command line parameter that cannot be identified as a property match or value
                        //so store it for later.
                        leftoverParams.AppendFormat("{0} ", arguments[i]);
                    }
                }

                //we've finished processing the command line so if we have any leftover parameters we need to try 
                //to find a place to put them.
                if (leftoverParams.Length > 0)
                {
                    //this means we have left over arguments, so let's figure out what to do with them.
                    foreach (ItemPropertyInfo info in propertyData.Values)
                    {
                        if (info.ValueFromRemainingArguments)
                        {
                            //we're going to set the remaining arguments to this property, but we need to get 
                            //the existing data out first so that we don't overwrite it

                            string value = (string)info.PropertyInfo.GetValue(target, null);
                            if (String.IsNullOrEmpty(value))
                            {
                                info.PropertyInfo.SetValue(target, leftoverParams.ToString(), null);
                            }
                            else
                            {
                                info.PropertyInfo.SetValue(target, String.Format("{0} {1}", value, leftoverParams.ToString()), null);
                            }
                            //once it's been stored, we don't need to keep going.
                            break;
                        }
                    }
                }

                ValidateCommandLineParameters<T>(ref target, ref propertyData);
            }
            catch (Exception ex)
            {
                retval = false;
                if (showHelp)
                {
                    if (propertyData != null)
                    {
                        ShowHelp(ex, ref propertyData);
                    }
                    else
                    {
                        //we don't have the property data, so write and error message
                        ConsoleColor colorCache = Console.ForegroundColor;
                        Console.ForegroundColor = ConsoleColor.Red;
                        Console.WriteLine("Unable to read property data from the specified class, so unable to show help.  In addition, the following exception was thrown:\n\n{0}", ex.Message);
                        Console.ForegroundColor = colorCache;
                    }
                }
                if (!supressExceptions)
                {
                    throw ex;
                }
            }
            return retval;
        }

        private static void ValidateCommandLineParameters<T>(ref T target, ref PropertyDictionary propertyData)
        {
            bool processValidations = false;
            //Determine if we're expected to validate the values in the parameters
            object[] validateAttr = target.GetType().GetCustomAttributes(typeof(ValidateArgumentsAttribute), false);
            if (validateAttr != null && validateAttr.Length > 0)
            {
                //the ValidateArgumentsAttribute is set, so we're expected to validate the parameters
                processValidations = true;
            }

            //loop through the properties and confirm that the required ones are specified.  If we're supposed 
            //to process validations, then do that at the same time.
            foreach (ItemPropertyInfo itemInfo in propertyData.Values)
            {
                object propertyValue = itemInfo.PropertyInfo.GetValue(target, null);
                if (itemInfo.Mandatory)
                {
                    if (propertyValue == null)
                    {
                        throw new Exception(String.Format("[{0}] Parameter Validation Error - This required parameter must be specified.", itemInfo.PropertyInfo.Name));
                    }
                }
                if (processValidations)
                {
                    foreach (IValidation validation in itemInfo.ValidationList)
                    {
                        if (!validation.IsValid(propertyValue))
                        {
                            throw new Exception(String.Format("[{0}] Parameter Validation Error - {1}", itemInfo.PropertyInfo.Name, validation.StatusMessage));
                        }
                    }
                }
            }
        }

        private static int GetMatchCount(string value, ref PropertyDictionary properties)
        {
            int matchCount = 0;
            foreach (string listItem in properties.Keys)
            {
                //look for a match by checking to see if the value is the first part of the comparison string
                if (listItem.IndexOf(value, StringComparison.CurrentCultureIgnoreCase) == 0)
                {
                    matchCount++;
                }
            }
            return matchCount;
        }

        private static string GetParameterValueByIndex(int index, string[] arguments)
        {
            string retval = String.Empty;
            //if we have a value past the index passed in then inspect it
            if (index + 1 < arguments.Length)
            {
                //if the next value in the list starts with a dash or slash then ignore it, otherwise return it
                if (arguments[index + 1][0] != '-' && arguments[index + 1][0] != '/')
                {
                    retval = arguments[index + 1];
                }
            }
            return retval;
        }

        private static void ShowHelp(ref PropertyDictionary propInfo)
        {
            const int INDENT = 4;
            Assembly myAssembly = Assembly.GetEntryAssembly();
            string name = String.Format("{0}", myAssembly.GetName().Name);
            Console.WriteLine("NAME\n{0}{1}\n", new String(' ', INDENT), name);
            Console.Write("SYNOPSIS\n{0}", new string(' ', INDENT));

            object[] descriptions = myAssembly.GetCustomAttributes(typeof(AssemblyDescriptionAttribute), false);
            if (descriptions.Length > 0)
            {
                AssemblyDescriptionAttribute description = descriptions[0] as AssemblyDescriptionAttribute;
                if (description.Description.Trim().Length > 0)
                {
                    WrapConsole.WriteLine(INDENT, description.Description);
                }
                else
                {
                    WrapConsole.WriteLine(INDENT, "(no description provided)");
                }
            }
            else
            {
                WrapConsole.WriteLine(INDENT, "(no description provided)");
            }

            Console.Write("\nSYNTAX\n{0}{1} ", new String(' ', INDENT), name);

            WrapConsole.WriteLine(4, GetParameterList(ref propInfo));
            Console.WriteLine("\nREMARKS\n{0}{1}", new String(' ', INDENT), String.Format("For more information, type \"{0} -detailedhelp\".", name));
            WrapConsole.WriteLine(4, String.Format("\n{0}{1}", new string(' ', INDENT), "When specifying names for parameters, the name can be abbreviated as long as the abbreviation used is not ambiguous."));
        }

        private static void ShowHelp(Exception ex, ref PropertyDictionary propInfo)
        {
            ConsoleColor colorCache = Console.ForegroundColor;
            Console.ForegroundColor = ConsoleColor.Red;
            Console.WriteLine("{0}\n", ex.Message);
            Console.ForegroundColor = colorCache;
            ShowHelp(ref propInfo);
        }

        private static void ShowDetailedHelp(ref PropertyDictionary propInfo)
        {
            const int INDENT = 4;
            Assembly myAssembly = Assembly.GetEntryAssembly();
            string name = String.Format("{0}", myAssembly.GetName().Name);
            Console.WriteLine("NAME\n{0}{1}\n", new String(' ', INDENT), name);
            Console.Write("SYNOPSIS\n{0}", new string(' ', INDENT));

            object[] descriptions = myAssembly.GetCustomAttributes(typeof(AssemblyDescriptionAttribute), false);
            if (descriptions.Length > 0)
            {
                AssemblyDescriptionAttribute description = descriptions[0] as AssemblyDescriptionAttribute;
                if (description.Description.Trim().Length > 0)
                {
                    WrapConsole.WriteLine(INDENT, description.Description);
                }
                else
                {
                    WrapConsole.WriteLine(INDENT, "(no description provided)");
                }
            }
            else
            {
                WrapConsole.WriteLine(INDENT, "(no description provided)");
            }

            Console.Write("\nSYNTAX\n{0}{1} ", new String(' ', INDENT), name);
            WrapConsole.WriteLine(INDENT, GetParameterList(ref propInfo));
            Console.WriteLine("\nPARAMETERS");
            foreach (string paramName in propInfo.Keys)
            {
                Console.Write("{0}-{1} ", new String(' ', INDENT), paramName);

                if (propInfo[paramName].PropertyInfo.PropertyType != typeof(bool))
                {
                    Console.Write("<{0}> ", propInfo[paramName].PropertyInfo.PropertyType.Name);
                }
                if (!propInfo[paramName].Mandatory)
                {
                    Console.Write("[optional] ");
                }
                Console.Write("\n{0}", new String(' ', INDENT * 2));
                WrapConsole.WriteLine(INDENT * 2, propInfo[paramName].HelpMessage);
                Console.WriteLine();
            }

            Console.WriteLine("{0}-help [optional]\n{1}Displays an abbreviated help screen.\n", new String(' ', INDENT), new String(' ', INDENT * 2));
            Console.WriteLine("{0}-detailedhelp [optional]\n{1}Displays a detailed help screen.", new String(' ', INDENT), new String(' ', INDENT * 2));

            Console.Write("\nREMARKS");
            WrapConsole.WriteLine(INDENT, String.Format("\n{0}{1}", new string(' ', INDENT), "When specifying names for parameters, the name can be abbreviated as long as the abbreviation used is not ambiguous."));
        }

        /// <summary>
        /// Reads the list of properties and formats them as a string of property names with types, showing the optional parameters in square brackets.
        /// This is passed in as a ref parameter so that the entire structure doesn't need to be copied in memory.
        /// </summary>
        /// <param name="propInfo">The property dictionary containing the properties</param>
        /// <returns>The formatted string of property names</returns>
        private static string GetParameterList(ref PropertyDictionary propInfo)
        {
            StringBuilder paramList = new StringBuilder();
            foreach (string param in propInfo.Keys)
            {
                if (propInfo[param].Mandatory)
                {
                    paramList.AppendFormat("-{0}{1} ", param, GetParameterTypeString(propInfo[param].PropertyInfo.PropertyType));
                }
                else
                {
                    paramList.AppendFormat("[-{0}{1}] ", param, GetParameterTypeString(propInfo[param].PropertyInfo.PropertyType));
                }
            }
            paramList.Append("[-help] [-detailedhelp]");
            return paramList.ToString();

        }
        /// <summary>
        /// Takes a data type and formats it for displaying command line usage.  
        /// </summary>
        /// <param name="type">The type to display</param>
        /// <returns>The display text</returns>
        private static string GetParameterTypeString(Type type)
        {
            string retval = String.Empty;
            if (type.Name != typeof(bool).Name)
            {
                retval = String.Format(" <{0}>", type.Name);
            }
            return retval;
        }


        private static PropertyDictionary GetPropertyDataFromTarget(object target)
        {
            PropertyDictionary propertyData = new PropertyDictionary();

            //locate all parameter attributes and assign values to the fields or properties
            PropertyInfo[] propertyInfo = target.GetType().GetProperties();
            foreach (PropertyInfo prop in propertyInfo)
            {
                ItemPropertyInfo itemPropertyInfo = new ItemPropertyInfo();

                if (prop.IsDefined(typeof(ParameterAttribute), false))
                {
                    //this is one of our parameters, so dig deeper to get the rest of the info
                    object[] customAttributes = prop.GetCustomAttributes(typeof(ParameterAttribute), false);
                    if (customAttributes.Length > 1)
                    {
                        throw new Exception("[Parameter] attribute cannot be used more than once per property.");
                    }
                    //we know the attribute is defined because we've checked and we know there's only one because
                    //we checked for that too, so the one we want must be at index 0.
                    ParameterAttribute paramAttr = (ParameterAttribute)customAttributes[0];
                    //store relvant property info
                    itemPropertyInfo.HelpMessage = paramAttr.HelpMessage;
                    itemPropertyInfo.Mandatory = paramAttr.Mandatory;
                    itemPropertyInfo.ValueFromRemainingArguments = paramAttr.ValueFromRemainingArguments;
                    itemPropertyInfo.PropertyInfo = prop;

                    //read the rest of the attributes for our validation attributes. ignore everything else
                    foreach (object attribute in prop.GetCustomAttributes(false))
                    {
                        IValidation validationAttribute = attribute as IValidation;
                        if (validationAttribute != null)
                        {
                            //this is one that we want, so save it.
                            itemPropertyInfo.ValidationList.Add(validationAttribute);
                        }
                    }

                    //save the property and its metadata
                    propertyData.Add(prop.Name, itemPropertyInfo);
                }
            }

            return propertyData;
        }

        private static object ConvertParameter(string sourceValue, Type targetType)
        {
            object retval=null;
            try
            {
                if (targetType.IsArray)
                {
                    retval = sourceValue.Split(',');
                }
                else
                {
                    retval = Convert.ChangeType(sourceValue, targetType);
                }
            }
            catch (Exception ex)
            {
                throw new Exception(String.Format("Unable to convert command line parameter to {0}.", targetType.Name), ex);
            }

            return retval;
        }
    }
}
